Изменение схемы данных с помощью space:format()¶
В этом руководстве рассказано, как разработать типовое приложение в Tarantool DB и изменить в нем схему данных, используя метод space:format(). В качестве примера используется база данных для системы управления проектами. Для работы используются модули migrations, CRUD и vshard.
Руководство включает следующие шаги:
Пререквизиты¶
Для выполнения примера требуются:
- установленный Docker-образ Tarantool DB; 
- приложение Docker compose; 
- утилита TT CLI; 
- исходные файлы примера - migrations.- Примечание - Есть два способа получить исходные файлы примера: - Архив с полной документацией Tarantool DB, полученный по почте или скачанный в личном кабинете tarantool.io. Пример архива: - tarantooldb-documentation-1.0.0.tar.gz. Пример- migrationsрасположен в таком архиве в директории- ./doc/examples/migrations/.
- Отдельный архив migrations.tar.gz, скачанный c сайта Tarantool. 
 
Схема данных¶
В качестве примера приведена база данных для системы управления проектами, которая состоит из трех спейсов: projects (проекты), tasks (задачи),
users (пользователи).
Схема этой базы данных выглядит так:
Здесь:
- Спейсы - projectsи- tasksимеют одинаковый ключ шардирования- project_idи находятся на одном экземпляре.
- Спейс - usersимеет ключ шардирования- user_id.
Особенности базы данных:
- Задач на проекте больше, чем пользователей. 
- При удалении проекта нужно удалить и связанные с ним задачи. Это удобно делать, если все записи находятся на одном экземпляре. 
Примечание
При выборе ключа шардирования учитывайте предметную область и предполагаемое API.
Для работы с данными в примере используются методы модуля CRUD. Дополнительно будет реализовано следующее API:
- app.delete_user(user_id)– удаление пользователя. У всех задач, связанных с этим пользователем, в поле- assigned_user_idдолжен быть выставлен- box.NULL;
- app.delete_project(project_id)– удаление проекта и всех связанных с ним задач;
- app.get_project_data(project_id)– получение проекта и всех связанных с ним задач и пользователей.
Запуск стенда¶
Для запуска и настройки кластера используются файлы из папки migrations:
- docker-compose.yml– описание узлов кластера;
- bootstrap/topology.json– топология кластера.
Для успешного старта должны быть свободны следующие порты:
- 3300 .. 3304 
- 8080 .. 8084 
Перейдите в директорию примера migrations:
cd ./doc/examples/migrations/
Запустите стенд:
docker compose up -d
В запущенном кластере созданы спейсы projects, tasks и users, а также
функции app.delete_user(user_id) и app.get_project_data(project_id).
Загрузка и проверка данных¶
Подключитесь к роутеру с помощью команды tt connect.
Команда открывает интерактивную консоль Tarantool, позволяющую работать с базой данных:
tt connect admin:secret-cluster-cookie@localhost:3300
Исходный код миграции приведен в файле 001_test.lua в директории ./bootstrap/migrations/source/ примера migrations.
Загрузить тестовые данные можно с помощью функции __create_example_data.
Функция очищает кластер и заполняет его данными из примера:
localhost:3300> box.schema.func.call('__create_example_data')
Чтобы проверить загруженные данные, выполните несколько базовых операций в спейсе users, используя модуль CRUD:
localhost:3300> crud.select('users')
---
- metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
  rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'john_doe', 'john.doe@example.com']
  - [1e63739a-dad0-4c5d-80e4-cd39594fe302, 1985, 'jane_smith', 'jane.smith@example.com']
- null
...
localhost:3300> crud.select('users', {{"==", "name", "john_doe"}})
---
- metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
  rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'john_doe', 'john.doe@example.com']
- null
localhost:3300> crud.update('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'), {{'=', 'name', "John Doe"}})
---
- rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'John Doe', 'john.doe@example.com']
  metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
- null
localhost:3300> crud.get('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))
---
- rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'John Doe', 'john.doe@example.com']
  metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
- null
localhost:3300> crud.delete('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))
---
- rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'John Doe', 'john.doe@example.com']
  metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
- null
...
Чтобы вернуть данные в прежнее состояние, вызовите функцию __create_example_data еще раз:
localhost:3300> box.schema.func.call('__create_example_data')
Проверка пользовательских функций базы данных¶
Модуль CRUD упрощает работу с шардированными данными – выполнение простых операций
чтения и записи таких данных прозрачно для пользователя.
Тем не менее, для задач, реализующих функции базы данных (app.get_project_data(id), app.delete_user(id) и app.delete_project(id)),
модуля CRUD недостаточно.
Это связано с тем, что эти функции работают с несколькими спейсами и нестандартными операциями чтения и записи.
Получение данных проекта¶
Для проверки функции app.get_project_data(project_id) верните данные в первоначальное состояние:
localhost:3300> box.schema.func.call('__create_example_data')
Вызовите функцию и оцените результат:
localhost:3300> box.schema.func.call('app.get_project_data', require('uuid').fromstr('46f8e628-d2c2-42ba-984f-29a459a3d0fc'))
---
- res:
    tasks:
    - status: In Progress
      user:
        name: john_doe
        email: john.doe@example.com
      name: Optimize Database
      description: Optimize the database to improve performance.
    name: Task Management
    description: Development of a task management system
  err: null
Функция app.get_project_data(project_id) выполняет join из всех спейсов, используются crud.get, crud.pairs.
Удаление пользователя¶
Для проверки функции app.delete_user(user_id) верните данные в первоначальное состояние:
localhost:3300> box.schema.func.call('__create_example_data')
Удалите пользователя:
localhost:3300> box.schema.func.call('app.delete_user', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))
---
- res: true
  err: null
...
localhost:3300> box.schema.func.call('app.get_project_data', require('uuid').fromstr('46f8e628-d2c2-42ba-984f-29a459a3d0fc'))
---
- res:
    tasks:
    - status: In Progress
      name: Optimize Database
      description: Optimize the database to improve performance.
    name: Task Management
    description: Development of a task management system
  err: null
localhost:3300> crud.get('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))
---
- rows: []
  metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
- null
...
В выводе функции видно, что информации о пользователе нет – пользователь успешно удален.
Теперь во всех связанных с этим пользователем задачах нужно присвоить полю assigned_user_id значение box.NULL.
Для этого на всех хранилищах была объявлена функция tasks.set_box_NULL_for_user_id.
Функция задает box.NULL в поле assigned_user_id для всех задач, у которых assigned_user_id == user_id, где
user_id – аргумент функции.
Код функции:
function(user_id)
  local fiber = require('fiber')
  local every_100 = 0
  for _, t in box.space.tasks.index.assigned_user_id:pairs({user_id}, 'EQ') do
      box.space.tasks:update(t.id, {{'=', 'assigned_user_id', box.NULL}})
      every_100 = every_100 + 1
      if every_100 == 100 then
          every_100 = 0
          fiber.yield() -- выполняем fiber.yield(), чтобы не занимать полностью TX тред в случае, когда задач у пользователя очень много
      end
  end
  return true
end
Особенность app.delete_user(id) состоит в том, что спейсы  tasks и users шардируются по разным ключам.
В общем случае связанные задачи и пользователи будут находиться на разных шардах. Это значит, что нет узла, на котором бы было известно,
на каких шардах будут задачи, связанные с удаляемым пользователем.
Вызывать функцию tasks.set_box_NULL_for_user_id нужно на каждом мастере шарда, потому что такие задачи будут на всех
шардах.
Для вызова функции на всех шардах используется модуль для горизонтального масштабирования vshard.
Вызов функции tasks.set_box_NULL_for_user_id выглядит так:
local _, err, uuid = vshard_router.map_callrw('tasks.set_box_NULL_for_user_id', {user_id})
Полный исходный код приведен в файле миграции ./bootstrap/migrations/source/001_test.lua примера migrations.
Удаление проекта¶
Для проверки функции app.delete_project(project_id) верните данные в первоначальное состояние:
localhost:3300> box.schema.func.call('__create_example_data')
Просмотрите содержимое спейса projects. Видно, что проект Website Update был удален вместе со всеми задачами:
localhost:3300> crud.select('projects')
---
- metadata: [{'name': 'id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}]
  rows:
  - [46f8e628-d2c2-42ba-984f-29a459a3d0fc, 1033, 'Task Management', 'Development of
      a task management system']
  - [f53392af-30e3-4bfc-bde8-37043951159a, 21589, 'Website Update', 'Making changes
      to the website design and functionality']
- null
localhost:3300> crud.select('tasks')
---
- metadata: [{'name': 'task_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}, {'name': 'status', 'type': 'string'}, {'name': 'project_id',
      'type': 'uuid'}, {'type': 'uuid', 'name': 'assigned_user_id', 'is_nullable': true}]
  rows:
  - [5043a3f6-6ffa-4d90-8b66-4fb623878f8e, 1033, 'Optimize Database', 'Optimize the
      database to improve performance.', 'In Progress', 46f8e628-d2c2-42ba-984f-29a459a3d0fc,
    04e7f6a2-2979-46e4-8d71-e80217e3aac3]
  - [c57d56ef-33fc-453b-880f-5d3ba4dc9d10, 21589, 'Create New Logo', 'Design a new
      logo for the website.', 'Not Started', f53392af-30e3-4bfc-bde8-37043951159a,
    1e63739a-dad0-4c5d-80e4-cd39594fe302]
- null
localhost:3300> box.schema.func.call('app.delete_project', require('uuid').fromstr('f53392af-30e3-4bfc-bde8-37043951159a'))
---
- res: true
  err: null
...
localhost:3300> crud.select('projects')
---
- metadata: [{'name': 'project_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}]
  rows:
  - [46f8e628-d2c2-42ba-984f-29a459a3d0fc, 1033, 'Task Management', 'Development of
      a task management system']
- null
localhost:3300> crud.select('tasks')
---
- metadata: [{'name': 'task_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}, {'name': 'status', 'type': 'string'}, {'name': 'project_id',
      'type': 'uuid'}, {'type': 'uuid', 'name': 'assigned_user_id', 'is_nullable': true}]
  rows:
  - [5043a3f6-6ffa-4d90-8b66-4fb623878f8e, 1033, 'Optimize Database', 'Optimize the
      database to improve performance.', 'In Progress', 46f8e628-d2c2-42ba-984f-29a459a3d0fc,
    04e7f6a2-2979-46e4-8d71-e80217e3aac3]
- null
...
Спейсы projects и tasks шардируются по одинаковым значениям.
Это означает, что связанные между собой проект и задача находятся на одном экземпляре.
Такой подход позволяет транзакционно удалить данные из projects и tasks.
Для этого на хранилищах реализована API-функция projects.delete_project.
Код функции:
function(project_id)
    local proj = box.space.projects:get(project_id)
    if proj == nil then
        return false
    end
    box.atomic(function() -- атомарно удаляем и из projects и из tasks
        box.space.projects:delete(project_id)
        for _, t in box.space.tasks.index.project_id:pairs({project_id}, 'EQ') do
            box.space.tasks:delete(t.id)
        end
    end)
    return true
end
Для вызова функции на конкретном мастере используется модуль vshard.
Вызов функции projects.delete_project выглядит так:
local vshard_router = require('vshard.router')
local bucket_id = vshard_router.bucket_id_strcrc32(id)
local _, err = vshard_router.callrw(bucket_id, 'projects.delete_project', {id})
Полный исходный код приведен в файле миграции ./bootstrap/migrations/source/001_test.lua примера migrations.
Изменение схемы данных¶
Предположим, что теперь нужно изменить схему данных, добавив в нее новые поля:
- deadline (datetime)в спейс- projects;
- due_date (datetime)в спейс- tasks;
- role (string)в спейс- users.
По умолчанию в полях projects.deadline и due_date(datetime) должно быть значение 2999-12-31T00:00:00Z, а в поле
users.role – значение not set.
Функцию app.get_project_data нужно также переписать, чтобы отображались новые поля.
Новая схема данных будет выглядеть так:
Код миграции приведен в файле ./migrations/002_test.lua002_test.lua примера migrations.
Миграции выполняются в лексикографическом порядке, поэтому им даны нумерованные названия: (0001_my_migr.lua, 2023_12_24_migr.lua).
Подготовьте данные для миграции:
localhost:3300> box.schema.func.call('__create_example_data')
Способы выполнения миграции¶
Есть два способа выполнить миграцию:
- в веб-интерфейсе Tarantool DB; 
- с помощью GraphQL. 
Веб-интерфейс
- Откройте вкладку Code в меню слева. 
- Добавьте в - migrations/sourceфайл- 002_test.lua.
- Скопируйте код из файла - 002_test.luaв этот файл.
- Нажмите кнопку - Apply.
- Конфигурация успешно применена. 
Graphql API
Для выполнения миграции запустите следующий запрос на изменение (мутацию):
curl -v --raw 'http://localhost:8081/admin/api' -X POST --data '{
        "query":"mutation($sections: [ConfigSectionInput!]) {
            cluster {
                config(sections: $sections) {
                    filename
                    content
                }
            }
        }",
        "variables": {
            "sections": [{
                "filename":"migrations/source/002_test.lua",
                "content":"'"$(cat 002_test.lua | sed 's/"/\\"/g' )"'"
            }]
        }
}'
Чтобы выполнить старт миграции, запустите в консоли следующую команду:
curl -X POST localhost:8081/migrations/up
Дождитесь ответа:
{"applied":["002_test.lua"]}
Проверьте, что миграция прошла успешно. Видно, что добавлены новые поля со значениями по умолчанию:
localhost:3300> crud.select('projects')
---
- metadata: [{'name': 'project_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}, {'type': 'datetime', 'name': 'deadline', 'is_nullable': true}]
  rows:
  - [46f8e628-d2c2-42ba-984f-29a459a3d0fc, 1033, 'Task Management', 'Development of
      a task management system', '2999-12-31T00:00:00Z']
  - [f53392af-30e3-4bfc-bde8-37043951159a, 21589, 'Website Update', 'Making changes
      to the website design and functionality', '2999-12-31T00:00:00Z']
- null
...
localhost:3300> crud.select('tasks')
---
- metadata: [{'name': 'task_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}, {'name': 'status', 'type': 'string'}, {'name': 'project_id',
      'type': 'uuid'}, {'type': 'uuid', 'name': 'assigned_user_id', 'is_nullable': true},
    {'type': 'datetime', 'name': 'due_date', 'is_nullable': true}]
  rows:
  - [5043a3f6-6ffa-4d90-8b66-4fb623878f8e, 1033, 'Optimize Database', 'Optimize the
      database to improve performance.', 'In Progress', 46f8e628-d2c2-42ba-984f-29a459a3d0fc,
    04e7f6a2-2979-46e4-8d71-e80217e3aac3, '2999-12-31T00:00:00Z']
  - [c57d56ef-33fc-453b-880f-5d3ba4dc9d10, 21589, 'Create New Logo', 'Design a new
      logo for the website.', 'Not Started', f53392af-30e3-4bfc-bde8-37043951159a,
    1e63739a-dad0-4c5d-80e4-cd39594fe302, '2999-12-31T00:00:00Z']
- null
...
localhost:3300> crud.select('users')
---
- metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true},
    {'type': 'string', 'name': 'role', 'is_nullable': true}]
  rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'john_doe', 'john.doe@example.com',
    'not set']
  - [1e63739a-dad0-4c5d-80e4-cd39594fe302, 1985, 'jane_smith', 'jane.smith@example.com',
    'not set']
- null
...
Теперь проверьте функцию app.get_project_data.
В функции должны появиться новые поля в ответе:
localhost:3300> box.schema.func.call('app.get_project_data', require('uuid').fromstr('46f8e628-d2c2-42ba-984f-29a459a3d0fc'))
---
- res:
    tasks:
    - due_date: 2999-12-31T00:00:00Z
      status: In Progress
      user:
        email: john.doe@example.com
        name: john_doe
        role: not set
      name: Optimize Database
      description: Optimize the database to improve performance.
    deadline: 2999-12-31T00:00:00Z
    name: Task Management
    description: Development of a task management system
  err: null
...
Теперь нужно изменить функцию app.get_project_data.
Для этого транзакционно удалите старую функцию и добавьте новую.
Такой подход гарантирует, что не произойдет ситуации, когда функции app.get_project_data не существует.
 box.atomic(function()
    box.schema.func.drop('app.get_project_data') -- удаляется старый вариант
    box.schema.func.create('app.get_project_data',  {
      language = 'LUA',
        if_not_exists = true,
        body = [[ ... ]]
    }) -- добавляем новый вариант
end)
Узнать подробнее о том, как хранятся персистентные функции, можно в спейсе box.space._func.